Skip to content

[WA-3252] Show CGW errors clearly: map known response states to agreed UI copy - #8592

Open
Clóvis Neto (clovisdasilvaneto) wants to merge 3 commits into
devfrom
fix/WA-3252-map-cgw-response-states-to-ui-copy
Open

[WA-3252] Show CGW errors clearly: map known response states to agreed UI copy#8592
Clóvis Neto (clovisdasilvaneto) wants to merge 3 commits into
devfrom
fix/WA-3252-map-cgw-response-states-to-ui-copy

Conversation

@clovisdasilvaneto

@clovisdasilvaneto Clóvis Neto (clovisdasilvaneto) commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What it solves

Resolves: WA-3252

A 502 from CGW rendered to users as raw HTML while submitting a transaction (reported by Liliya). The underlying problem is bigger than one status code: there was no agreed contract for what the UI shows on known CGW responses, so codes were handled ad hoc or not at all.

Where the HTML actually escaped

Not at the render layer — two layers earlier, in shared code.

RTK Query cannot JSON-parse a gateway's HTML error page, so fetchBaseQuery returns { status: 'PARSING_ERROR', originalStatus: 502, data: '<html>…502 Bad Gateway…nginx…' }. In packages/utils/src/services/exceptions/utils.ts, asError's branch order hit typeof thrown.data === 'string' first and hoisted the entire HTML body into error.message, while error.status became the string 'PARSING_ERROR' — so the real 502 was discarded and getHttpStatusFromError returned undefined.

From there it surfaced verbatim in two places: proposeTransactionTxEvent.PROPOSE_FAILEDuseTxNotificationsdetailedMessage rendered inside a <pre>; and the same error object into TxSubmitErrorErrorMessage details.

Fixing it in asError closes the leak for every consumer at once — including mobile, which shares that function.

How this PR fixes it

Shared (packages/)

  • exceptions/gatewayErrors.ts (new) — the code-keyed copy source, sitting beside contractErrors.ts and shaped like it. 422 / 429 / 451 explicit plus a 5xx range rule.
  • exceptions/utils.tsasError no longer surfaces an unparsable or markup body as the message, and preserves originalStatus as a numeric status; getHttpStatusFromError now also reads originalStatus.
  • exceptions/ErrorCodes.ts / errorTaxonomy.ts — one line each (_622), so logError has a code to take and the WA-2775 facets don't degrade to type: unknown.

Web (apps/web/)

  • utils/cgw-errors.ts (new) — getCgwErrorInfo / getCgwSupportCode; every component edit calls into it rather than spreading conditionals.
  • ErrorMessage — a known CGW state shows Error code CGW-502 with a copy button instead of a Details toggle over the payload.
  • TxSubmitError — one self-contained branch after the rate-limit branch.
  • useTxNotifications / useSafeMessageNotifications — the mapped sentence, and detailedMessage: undefined for CGW errors.
  • store/middleware/cgwErrorAlert.ts (new) — the 422 internal alert.

The agreed contract

CGW response Message
429, 502, 422, any other 5xx Something went wrong on our end. Try again.
451 — banned Safe This Safe Account is not available.

A 422 means we sent CGW a malformed request — our bug, not the user's — so it emits an internal alert via logError(Errors._622, …)logger.warn + captureError({ isUserFacing: false }), i.e. the Datadog debugging sink.

On analytics

trackErrorSurfaced and normalizeError are untouched. What reaches Mixpanel is only what it already sent: normalized enums plus http_status, a number already whitelisted in mapContext. No response body, no message text, no new property. The WA-2775 privacy invariant ("the sanitized message stays out of Mixpanel") is intact.

Deliberately out of scope

Three scope decisions, all deliberate:

  • All 404 handling. The ticket asks for a genuine-404 message and a benign-404 silent empty state, but never defines how to tell them apart — and both are literally 404 with the same response shape. Guessing would either suppress real errors or pollute normal empty states, so it is deferred to a follow-up. gatewayErrors.ts leaves 404 unmapped, with tests pinning that, and existing 404 suppression (useLoadSafeInfo's isCgw404) is untouched.
  • Retry with backoff. Putting retry() on the shared cgwClient base query cannot satisfy both platforms without changing mobile's test harness (jest.setup.tsx calls jest.useFakeTimers() globally, so a backoff setTimeout never fires). A web-only retry would ship a silently asymmetric AC, so retry moves to its own ticket.
  • Toast-vs-inline placement. The ACs specify which message, not where. The open design question in the ticket thread is left for design; this PR keeps the existing placement.

Correction on the retry claim

An earlier revision of this description said "nothing retries CGW requests today". That is accurate for the submission flow this ticket fixes, but not repo-wide: packages/store/src/gateway/chains/index.ts:16 wraps dynamicBaseQuery in retry(…, { maxRetries: 5 }) with no retryCondition, so failures on the chains endpoints are re-sent. The propose/submission path genuinely has no retry, which is why AC4 ("422 does not retry-loop") holds for the defect flow. cgwClient-no-retry.test.ts pins the base query directly and therefore covers neither the chains wrapper nor a retry() added at createApi level — its comment has been corrected to say so rather than overclaim.

How to test it

yarn workspace @safe-global/web test --testPathPattern "(cgw-errors|cgwErrorAlert|TxSubmitError|ErrorMessage|useTxNotifications|useSafeMessageNotifications)"
yarn workspace @safe-global/utils test --testPathPattern "(gatewayErrors|exceptions/__tests__/utils)"

Manual — intercept the CGW request (Requestly, as QA does) and return each status while submitting a transaction:

  1. 502 → "Something went wrong on our end. Try again." and no HTML, no nginx, no status line.
  2. 422 → same message, shown once, plus an internal alert in Datadog.
  3. 451 → "This Safe Account is not available."
  4. 404 → unchanged from today (out of scope).

Affected flows

  • Submitting a transaction when CGW answers with a known error state (the original defect)
  • Signing an off-chain message on the same paths
  • Any surface rendering a CGW failure through ErrorMessage, TxSubmitError, or the tx/message notification toasts

Blast radius

Surface Consumers Change
packages/utils/.../exceptions/utils.ts (asError) web + mobile Highest-reach change here. An unparsable or markup body no longer becomes error.message; the real HTTP status is preserved. Mobile's full suite was run and passes.
packages/utils/.../gatewayErrors.ts new New shared copy source; currently read by web only (see follow-ups)
ErrorCodes.ts / errorTaxonomy.ts web + mobile One line each; additive
components/tx/ErrorMessage, TxSubmitError broad — many tx surfaces New branch; non-CGW errors take the identical path they did before
hooks/useTxNotifications, useSafeMessageNotifications global toasts detailedMessage withheld for CGW errors only
store/index.ts web store One middleware registration

Not touched: contractErrors.ts, trackErrorSurfaced, normalizeError, the Mixpanel payload, mobile source, CI, dependencies. apps/tx-builder does not depend on @safe-global/utils and is unaffected despite its preview job running.

Risks / not checked

  • No live CGW 502. The defect path is reproduced from RTK Query's actual PARSING_ERROR shape, not an end-to-end request against a failing gateway.
  • asError is shared with mobile. Its full suite passes (353 suites / 2930 tests) and mobile source is untouched, but the mobile app was not run.
  • isMarkup is /^\s*</. A plain-text error body that legitimately starts with < would be treated as markup and replaced with "Request failed with status N" — informative, but not the original text.
  • The 422 alert only fires for CGW calls that go through the RTK Query store, and has no dedup (see follow-ups).
  • This PR overlaps [WA-3243] Hardware-wallet errors reach users as "unknown": preserve and translate Ledger device errors (web) #8568 (WA-3243) in four files, including an add/add on hooks/__tests__/useTxNotifications.test.ts. The two are semantically independent — Ledger errors carry no HTTP status, so the CGW classifier cannot false-positive on them — but whichever merges second needs a mechanical conflict resolution.

Known gaps, tracked as follow-ups rather than widened into this PR

  • Mobile does not read gatewayErrors.ts. Mobile surfaces asError's output directly, so a CGW 502 there shows "Request failed with status 502" — no HTML leak, but a status line rather than the agreed copy.
  • "Is a CGW error" is inferred from "carries an HTTP status". Nothing checks the error actually originated at CGW, so an unwrapped transport error carrying status: 429 could be labelled CGW-429. The proper fix is branding the error at the throw site.
  • apps/web/src/utils/rtkQuery.ts remains a parallel generic-copy mechanism ("Something went wrong (502). Please try again…") used by chains and spaces flows. Pre-existing; worth consolidating onto this copy source.
  • The 422 alert has no dedup, so a persistently-422 polled endpoint could emit one Datadog event per poll tick.

Visual summary

flowchart TB
  G["CGW answers 502 with an HTML error page"]

  subgraph Before
    B1["fetchBaseQuery: JSON.parse fails"] --> B2["{ status: 'PARSING_ERROR',<br/>originalStatus: 502,<br/>data: '&lt;html&gt;…nginx…' }"]
    B2 --> B3["asError: data-is-string branch wins<br/>message = the whole HTML<br/>status = 'PARSING_ERROR'"]
    B3 --> B4["getHttpStatusFromError → undefined<br/>the real 502 is lost"]
    B3 --> B5["&lt;pre&gt; in the toast Details<br/>renders raw HTML"]
  end

  subgraph After
    A1["asError: PARSING_ERROR branch first"] --> A2["message = 'Request failed with status 502'<br/>status = 502 (numeric)"]
    A2 --> A3["getCgwErrorInfo"]
    A3 -->|"429 / 5xx / 422"| A4["'Something went wrong on our end. Try again.'"]
    A3 -->|"451"| A5["'This Safe Account is not available.'"]
    A3 -->|"404"| A6["unmapped — unchanged, deferred"]
    A4 --> A7["screen: sentence + Error code CGW-502"]
    A5 --> A7
    A4 --> A8["422 only: internal alert → Datadog"]
  end

  G --> B1
  G --> A1
Loading

Checklist

  • I've tested the branch on mobile 📱 — mobile source untouched; its full suite passes because asError is shared
  • I've documented how it affects the analytics (if at all) 📊
  • I've written a unit/e2e test for it (if applicable) 🧑‍💻
  • I've listed affected flows and blast radius, and named what I did not verify 🎯

A CGW 502 answered with an HTML error page was rendered verbatim to the
user while submitting a transaction. RTK Query cannot JSON-parse that body,
so it reports PARSING_ERROR and `asError` hoisted the raw HTML into
`error.message`, which the notification toast prints inside a <pre> and the
inline submit error prints in its Details panel.

There was also no agreed contract for what the UI shows on a known CGW
response, so codes were handled ad hoc or not at all.

- Add `gatewayErrors.ts` next to `contractErrors.ts`: one shared, code-keyed
  source of copy for 422 / 429 / 451 / any 5xx, read by web and mobile. 404 is
  deliberately left unmapped.
- Stop `asError` surfacing an unparsable or markup response body as the
  message, and keep the real HTTP status (`originalStatus`) so the UI can map
  it. `getHttpStatusFromError` now reads `originalStatus` too.
- Render the agreed copy in `TxSubmitError` and in the transaction and
  safe-message notification hooks; show the code-only support reference
  (`CGW-502`) in Details instead of the raw payload.
- Alert internally on a 422 (a malformed request is our bug) from a single
  RTK Query middleware. Only the numeric status reaches analytics.

Nothing retries CGW requests, so a 422 is surfaced once; a test pins that.
A retry policy for the transient states (429 / 5xx) is deferred to its own
ticket, since it cannot be delivered for both platforms without changing
mobile's test harness.
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

tx-builder Preview

✅ Deploy successful!

Preview URL:
https://fix-wa-3252-map-cgw-response-states-to-ui-copy--tx-builder.review.5afe.dev/

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

📦 Next.js Bundle Analysis for @safe-global/web

This analysis was generated by the Next.js Bundle Analysis action. 🤖

🎉 Global Bundle Size Decreased

Page Size (compressed)
global 1.53 MB (🟢 -4.05 KB)
Details

The global bundle is the javascript bundle that loads alongside every page. It is in its own category because its impact is much higher - an increase to its size means that every page on your website loads slower, and a decrease means every page loads faster.

Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis

If you want further insight into what is behind the changes, give @next/bundle-analyzer a try!

Twenty-nine Pages Changed Size

The following pages changed size from the code in this PR compared to its base branch:

Page Size (compressed) First Load
/address-book 22.99 KB (🟢 -93 B) 1.55 MB
/apps 44.98 KB (🟡 +1 B) 1.57 MB
/apps/custom 42.42 KB (🟡 +1 B) 1.57 MB
/apps/open 9.27 KB (🟢 -3 B) 1.53 MB
/balances 25.52 KB (-2 B) 1.55 MB
/dashboard/new 1.42 KB (🟡 +1 B) 1.53 MB
/new-safe/create 29.46 KB (-1 B) 1.55 MB
/new-safe/load 9.32 KB (🟡 +1 B) 1.53 MB
/settings/appearance 6.17 KB (🟢 -1 B) 1.53 MB
/settings/data 32.25 KB (🟢 -111 B) 1.56 MB
/settings/environment-variables 7.26 KB (🟢 -1 B) 1.53 MB
/settings/safe-apps 8.88 KB (🟢 -4 B) 1.53 MB
/settings/security 6.49 KB (🟢 -1 B) 1.53 MB
/settings/setup 37.8 KB (-1 B) 1.56 MB
/share/safe-app 6.62 KB (🟢 -2 B) 1.53 MB
/spaces/activity 548 B (🟡 +2 B) 1.53 MB
/spaces/address-book 510 B (🟡 +1 B) 1.53 MB
/spaces/create-space 442 B (🟡 +2 B) 1.53 MB
/spaces/members 500 B (🟡 +1 B) 1.53 MB
/spaces/safe-accounts 510 B (🟡 +3 B) 1.53 MB
/spaces/security 6.76 KB (🟡 +3 B) 1.53 MB
/spaces/settings/about 507 B (🟢 -1 B) 1.53 MB
/transactions 35.4 KB (🟡 +1 B) 1.56 MB
/transactions/history 35.36 KB (🟡 +1 B) 1.56 MB
/transactions/messages 14.17 KB (🟡 +2 B) 1.54 MB
/transactions/msg 6.29 KB (🟡 +3 B) 1.53 MB
/wc 588 B (🟡 +2 B) 1.53 MB
/welcome/spaces 309 B (🟡 +1 B) 1.53 MB
/welcome/survey 698 B (🟡 +1 B) 1.53 MB
Details

Only the gzipped size is provided here based on an expert tip.

First Load is the size of the global bundle plus the bundle for the individual page. If a user were to show up to your website and land on a given page, the first load size represents the amount of javascript that user would need to download. If next/link is used, subsequent page loads would only need to download that page's bundle (the number in the "Size" column), since the global bundle has already been downloaded.

Any third party scripts you have added directly to your app using the <script> tag are not accounted for in this analysis

Next to the size is how much the size has increased or decreased compared with the base branch of this PR. If this percentage has increased by 20% or more, there will be a red status indicator applied, indicating that special attention should be given to this.

@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Coverage report for apps/web

St.
Category Percentage Covered / Total
🟢 Statements
85.16% (+0% 🔼)
35450/41627
🟡 Branches
70.28% (-0.03% 🔻)
11605/16513
🟡 Functions
73.59% (+0.04% 🔼)
5405/7345
🟢 Lines
86.4% (-0% 🔻)
31721/36716
Show new covered files 🐣
St.
File Statements Branches Functions Lines
🟢
... / cgwErrorAlert.ts
100% 100% 100% 100%
🟢
... / cgw-errors.ts
100% 100% 100% 100%
🟢
... / useTxNotifications.ts
78.89% 53.13% 90% 80%

Test suite run success

7812 tests passing in 888 suites.

Report generated by 🧪jest coverage report action from a45231a

…WA-3252)

A 429-carrying error matches both the viem rate-limit classifier and the
CGW HTTP-status map. `TxSubmitError` checked rate-limit first, but
`useTxNotifications` checked the CGW branch first, so one and the same
failure rendered "Network is busy. Please try again in a moment." inline
and "Something went wrong on our end. Try again." on the toast.

Move the `cgwError` branch after the `isRateLimitError` branch so both
surfaces resolve the overlap identically. The CGW support reference
(`Error code CGW-429`) is unchanged and still accompanies the toast,
matching the inline alert's code-only reference.

Tests pin the ordering on both surfaces; swapping the branches back
fails them. Also sharpen two tests that could not fail for the reason
their names claimed, and correct the scope comment on the CGW retry pin:
it covers `dynamicBaseQuery` only, not the `retry()` wrapper in
`gateway/chains/index.ts`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ybxq8EGntf1FmNC7Q2kLz1
@clovisdasilvaneto
Clóvis Neto (clovisdasilvaneto) marked this pull request as ready for review August 26, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants